某天早上翻持倉狀態檔,一個數字讓我愣住:MU(美光)的現價欄寫著 $864。
那陣子 MU 的實際股價在一百六十幾美金附近(此為當時價位;重點不在絕對數字,而在 $864 是實價的五倍多)。$864 讓我的持倉檔理直氣壯地顯示這檔股票單日暴漲 400%,未實現損益一夜之間多了一輛機車的錢。
第一反應當然是爽。第二反應是冷汗:**如果我沒有親眼看到這個數字,晨報直接把它寫成「MU 大漲、獲利了結時機」呢?**如果我半夢半醒地看了報告,順手掛了一張賣單呢?一套每天自動運轉、我越來越信任的系統,餵了我一筆毒數據。
今天講這次事故的完整覆盤,以及事後補上的資料防呆層。這篇沒有新技術,但有一層比一層深的兩個教訓:先是「外部資料會餵你毒數據,你得加防呆」——這層大家都想得到;更難堪的是後半段那個續集:**連你加的那道防呆,本身都會變成一個永遠不會解除的故障。**第二層才是我真正想講的,前面的 $864 只是把你帶到那裡的入場券。
回溯 log,鏈路是這樣的:
注意這裡最陰險的一點:每一層都「正常運作」。API 有回應、JSON 能解析、腳本沒噴錯、報告準時送達。整條 pipeline 綠燈通行,卻運送著一筆毒貨。傳統的「有沒有報錯」監控在這種場景下完全失效——這不是可用性問題,是資料品質問題。

修復的核心思路很樸素:每一筆新價格,都要跟舊價格對質。
股價是連續性很強的數據——正常情況下,一天的波動極少超過 ±30%(就算真的漲停跌停,也遠到不了 400%)。所以在快取寫入前,加一道 sanity check:
const MAX_DAILY_CHANGE = 0.30; // 單日容忍波動 ±30%
function sanitize(symbol, newQuote, oldQuote) {
if (!oldQuote?.price) return newQuote; // 沒有舊值可比,先放行
const change = Math.abs(newQuote.price - oldQuote.price) / oldQuote.price;
if (change > MAX_DAILY_CHANGE) {
// 超出合理區間:拒收新值,保留舊值 + 標記 stale + 記錄事件
log.warn(`${symbol} 價格異常: ${oldQuote.price} → ${newQuote.price},已拒收`);
return { ...oldQuote, stale: true, reason: "PRICE_OUT_OF_RANGE" };
}
return newQuote;
}
幾個設計決策值得展開:
**超界時保留舊值,而不是寫入 null。**昨天講過的原則在這裡再次適用:一個「舊但大致正確」的價格,比「空值」或「新但離譜」的價格有用得多。下游拿舊值頂多是資訊延遲,拿到 null 會連損益都算不出來,拿到假值則會做錯決策——三種失敗模式裡,舊值的傷害最小。防呆設計的本質是選一種最便宜的失敗方式。
**閾值定 30% 而不是 10%。**太緊會誤殺:財報日的暴漲暴跌、股票分割(split)都是真實會發生的大波動。我的取捨是「寧可放過真的暴漲,不可錯殺成常態誤報」——因為誤報多了人會麻痺(Day 25 有一個更慘的誤報故事)。至於股票分割這種合法的價格腰斬,我會在 log 看到那筆異常,人工確認後更新成本基準即可,一年遇不到幾次。
拒收要留痕。每次拒收都寫進 log(老實說目前只到 log 這一層——單檔拒收不會主動推播給我,只有整支 job 連續失敗才會告警;「拒收也發推播」還是個待補的洞)。防呆不是把異常吞掉當沒事,而是把異常從「自動流入決策」降級成「留下痕跡、等人來看一眼」。
一個誠實的補充:在後來實際跑的版本裡,這道「相對變化率」檢查其實被我降成了警示,而不是硬攔截——變化率過大時往 log 記一筆等我看,但不擋它寫入。真正會拒收的是下一節續集要講的「每檔絕對區間 {min, max}」。兩道檢查的嚴重程度是分開的:**相對變化率負責『提醒我有異狀』,絕對區間負責『擋住明顯的髒資料』。**上面的偽碼把兩件事合在一起寫,是為了先把「拿新值跟舊值對質」這個核心觀念講清楚;真實系統裡它們是一軟一硬的兩層。
防護上線後我以為完事了,結果發現一個漏洞:快取裡的 stale 標記,下游報告根本沒在看。價格被攔下、舊值加註 stale,但晨報只叫 LLM「讀持倉檔寫摘要」,於是它拿著幾天前的舊價侃侃而談「今日走勢平穩」——我以為看到的是今天,其實是上週,這跟假數據一樣誤導。
修法是讓 stale 一路顯性化到人眼前:快取標 stale +原因碼 → 持倉檔備註「⚠️ 舊價」→ 晨報 prompt 在整體快取超過 8 小時時標註「⚠️ 數據可能過時」→ 健檢 job 快取超 25 小時未更新就告警。一句話:資料的「新鮮度」本身就是資料,要跟著數據一起流動;吞掉 stale 標記的報告,比沒有報告更危險。
上面那套防護上線後,我一直很滿意。直到某天翻投資報告,發現一個更難堪的東西——而這次的兇手是我自己加的防呆。
現在把上一節預告的第二道機制講清楚——除了「跟舊值比變化率」,還有每檔股票各自的絕對合理區間。因為變化率擋不住一種情況——如果爬蟲連續兩天都抓到同一個錯誤頁面,兩個錯值之間的「變化率」是 0,完美通過檢查。所以每檔各自寫死一組 {min, max},用來擋「正數但錯 10 倍」這種單位位移。
問題出在其中一檔的 max。
那檔股票當時股價約 2,380,我的觀察清單設了一條規則:跌到 2,650 以下就提醒我可以進場(它當時已經在門檻內)。而我隨手把它的 max 設成 2,500。
你可能已經看出來了:max 比觸發門檻還低。
後來股票漲了。漲破 2,500 的那一刻,防呆層盡責地判定「這個價格不合理」,拒收,保留舊值。於是:
這比原本的 $864 事故更糟糕,因為 $864 那次是一次性的錯值,看到就會覺得怪;這次是一個永遠不會解除的錯誤訊號,而且它每天都在「正確地」重複自己。快取沒有標 stale(因為它「成功保留了舊值」),報告沒有警告,監控全綠。
修法本身很簡單——把 max 調到門檻之上並留足上漲空間。但我另外做了一件更重要的事:把這個關係寫成一條測試。
那條測試不檢查任何具體數字,它檢查一個不變量:
每一檔的
max,都必須高於它在觀察清單裡的觸發門檻。
這樣寫的好處是,未來我調整任何門檻或邊界,只要不小心讓兩者交叉,測試會立刻紅燈——而不是等到某天股價漲上去、我做錯一次決策才發現。
這件事給我的教訓,比原本那篇的三條收穫都深:
一道防呆的邊界,如果比它要保護的訊號還緊,它不會保護你,它會製造一個永遠正確的錯誤。
邊界的用途是擋「單位位移」這種明顯的髒資料,不是擋真實行情。我當初設 2,500 的時候,心裡想的是「這檔不可能漲那麼多吧」——那句話本身就是問題。合理性檢查的邊界該來自「物理上不可能」(例如價格不可能是負的、不可能一夜十倍),而不是來自我對行情的預測。
事後檢討時我發現,其實事故前就有徵兆:備援源在更早幾天就回過一次奇怪的數值,只是那次剛好落在持倉之外的觀察清單股票上,沒人受害,我看到 log 裡的怪數字還想「免費源嘛,難免」。**「難免」是資料品質崩壞的第一聲敲門。**如果第一次看到怪值就加防護,後面根本不會有事故。異常不分有沒有造成損害,都值得一條規則。
這次事故的三條收穫:外部資料一律過合理性檢查、失敗時選最便宜的失敗方式(舊值 > 空值 > 假值)、資料新鮮度要一路透傳到人眼前。自動化系統最可怕的不是壞掉,是壞得很安靜。
加上續集的第四條,我認為它比前三條都重要:**你加的每一道防呆,本身也是一個會壞的東西。**它的邊界要比它守護的訊號寬,而且那個關係最好寫成一條測試——因為人會忘記,測試不會。
順帶埋個伏筆:今天這道守在資料入口的合理區間檢查,本質上是一道「資料閘道」——不合格的東西進不了系統。第四週(Day 24)會再看到一模一樣的形狀,只是守的對象從「外部資料」換成「我自己寫的設定」。閘道這個模式,值得在系統的每個入口都放一道。
聊完錢的防呆,明天換個場景聊「家」:台灣夏天濕度爬到 70% 以上會發霉的牆,和一套會自己把除濕機打開、乾了再關掉的 Home Assistant × Agent 整合——它一開始只是發訊息提醒我,後來才長成閉環。
🔑 這篇的關鍵字
sanity check / 資料合理性驗證 · 兩道互補的檢查:相對變化率(擋單次跳動)+ 每檔絕對{min,max}(擋連續抓到同一錯值,變化率為 0 會漏掉)· 失敗偏好排序:舊值 > 空值 > 假值 ·stale旗標一路透傳到報告層(新鮮度本身就是資料)· 拒收要留痕、要告警 · 不變量測試:護欄邊界必須寬於它守護的訊號門檻
我是一名金融業資訊工程師,這是我半年來在家自架 AI Agent 系統的實錄。